Skip to content

fix(jsonapi): allow opt-in client-generated IDs on POST per spec#7930

Merged
soyuka merged 1 commit into
api-platform:4.3from
abderrahimghazali:fix/jsonapi-post-id-not-required
Jun 5, 2026
Merged

fix(jsonapi): allow opt-in client-generated IDs on POST per spec#7930
soyuka merged 1 commit into
api-platform:4.3from
abderrahimghazali:fix/jsonapi-post-id-not-required

Conversation

@abderrahimghazali
Copy link
Copy Markdown
Contributor

Q A
Branch? main
Bug fix? yes
New feature? no (opt-in flag)
Deprecations? no
Issues Closes #6738
License MIT

What's in this PR?

Two related JSON:API issues prevent valid POST requests with client-generated IDs (per JSON:API §7.3):

  1. Schema (SchemaFactory) declared data.id required for every operation, including the request body of a POST. The spec says id MAY be supplied by the client and is otherwise optional on creation.
  2. Denormalizer (ItemNormalizer) treated any incoming data.id as a hint to load an existing resource, then either threw Update is not allowed for this operation or failed to resolve the IRI when the client passed a fresh UUID.

Together they make it impossible to POST {"data":{"type":"…","id":"<uuid>","attributes":{…}}} even when the application is designed for client-generated identifiers (Doctrine UUID PK, ULID, etc.).

Fix

JsonApi\JsonSchema\SchemaFactory

  • For Schema::TYPE_INPUT on a Post operation, data.required is now ["type"]. Output schemas and non-Post operations remain ["type", "id"] (response payloads still always carry an id).
  • The active operation is captured at the start of buildDefinitionPropertiesSchema() because the relationship loop reassigns \$operation.

Before (POST request body):
```json
"required": ["type", "id"]
```
After:
```json
"required": ["type"]
```

JsonApi\Serializer\ItemNormalizer

  • New opt-in context flag ItemNormalizer::ALLOW_CLIENT_GENERATED_ID ('allow_client_generated_id').
  • On a Post, an incoming data.id no longer triggers an existing-resource lookup.
    • If the flag is off (default): throws NotNormalizableValueException with a clear message — no behaviour change for endpoints that don't expect client-generated IDs, and no risk of silently letting a client spoof an ID.
    • If the flag is on: the id is merged into the denormalized payload and applied to the new entity via the property setter. The IRI converter is not queried.
  • Existing PUT/PATCH path (load by IRI / OBJECT_TO_POPULATE) is preserved.

The flag is off by default to keep the change non-breaking and to avoid an ID-spoofing footgun on public endpoints. Applications opt in per-operation by passing the flag in the denormalization context (or via a state processor / serializer context builder).

Tests

  • SchemaFactoryTest::testBuildSchemaForPostInputDoesNotRequireId — POST input drops id from required.
  • SchemaFactoryTest::testBuildSchemaForPostOutputStillRequiresId — POST output still requires id.
  • ItemNormalizerTest::testDenormalizePostWithIdThrowsWithoutOptIn — POST with client id throws without opt-in.
  • ItemNormalizerTest::testDenormalizePostWithIdSucceedsWithOptIn — POST with client id succeeds with opt-in, IRI converter is not called, id is set on the new entity.

Full src/JsonApi/Tests suite: 57 tests / 145 assertions, all green (the 2 PHPUnit notices are pre-existing).

Spec references

Credit to @cay89 for the original analysis in #6738.

// Per JSON:API spec, `id` is optional in the request body of a creation:
// https://jsonapi.org/format/#crud-creating
$required = ['type', 'id'];
if (Schema::TYPE_INPUT === $type && $resourceOperation instanceof Post) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we prefer $resourceOperation->getMethod()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated, I switched to 'POST' === $resourceOperation->getMethod() and dropped the now-unused Post import. Added a null guard since $resourceOperation is typed ?Operation. Thanks! 🙂

// Avoid issues with proxies if we populated the object
if (!isset($context[self::OBJECT_TO_POPULATE]) && isset($data['data']['id'])) {
if (true !== ($context['api_allow_update'] ?? true)) {
if ($operation instanceof Post) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same lets prefer the getMethod approach

@soyuka
Copy link
Copy Markdown
Member

soyuka commented Apr 27, 2026

Good alignment with the spec, we need to provide a configuration option that sets this context value, also this will be considered as a new feature and commit/pr title should be renamed accordingly.

@abderrahimghazali abderrahimghazali changed the title fix(jsonapi): allow opt-in client-generated IDs on POST per spec feat(jsonapi): allow opt-in client-generated IDs on POST per spec Apr 27, 2026
@abderrahimghazali
Copy link
Copy Markdown
Contributor Author

Done in 251eb81ItemNormalizer now uses getMethod() (and the Post import is gone), and ALLOW_CLIENT_GENERATED_ID can be enabled declaratively per operation via extraProperties:

#[Post(extraProperties: [ItemNormalizer::ALLOW_CLIENT_GENERATED_ID => true])]

Added a test covering that path. Renamed the PR title to feat(jsonapi): … accordingly. Thanks!


$allowClientGeneratedId = $context[self::ALLOW_CLIENT_GENERATED_ID] ?? null;
if (null === $allowClientGeneratedId && $isPostOperation) {
$allowClientGeneratedId = $operation->getExtraProperties()[self::ALLOW_CLIENT_GENERATED_ID] ?? false;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you should use the denormalization context directly we don't need to use extra properties here.


$result = $normalizer->denormalize($data, Dummy::class, ItemNormalizer::FORMAT, [
'operation' => new Post(extraProperties: [ItemNormalizer::ALLOW_CLIENT_GENERATED_ID => true]),
'operation' => new Post(),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice or it could be via denormalizerContext: [] in an operation (default context is fine for the test)

->defaultTrue()
->info('Set to false to use entity identifiers instead of IRIs as the "id" field in JSON:API responses.')
->end()
->booleanNode('allow_client_generated_id')
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you need to change ConfigurationTest::testDefaultConfig

Copy link
Copy Markdown
Member

@soyuka soyuka left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

run php-cs-fixer please

@soyuka soyuka force-pushed the fix/jsonapi-post-id-not-required branch 2 times, most recently from 151198b to 58e1bcb Compare June 5, 2026 12:18
@soyuka soyuka changed the base branch from main to 4.3 June 5, 2026 12:18
@soyuka soyuka changed the title feat(jsonapi): allow opt-in client-generated IDs on POST per spec fix(jsonapi): allow opt-in client-generated IDs on POST per spec Jun 5, 2026
Per https://jsonapi.org/format/#crud-creating-client-ids a client MAY
supply an id when creating a resource. The JSON:API ItemNormalizer
previously treated any incoming `data.id` on POST as a hint to load an
existing resource, throwing "Update is not allowed for this operation"
or failing the IRI lookup, even when the application is designed for
client-generated identifiers.

A new opt-in `ALLOW_CLIENT_GENERATED_ID` denormalization context flag
lets the client id flow through to the entity setter without querying
the IriConverter. Off by default to avoid an id-spoofing footgun on
public endpoints. Configurable globally via the Symfony bundle
(`api_platform.jsonapi.allow_client_generated_id`) and Laravel
(`api-platform.jsonapi.allow_client_generated_id`), per-operation via
`denormalizationContext`.

Also tightens the input-schema guard added in api-platform#8252: adds a
`Schema::TYPE_INPUT === $type` check so POST response schemas keep
requiring `id`, and captures the resource operation before the
relationship loop reassigns `$operation` so the requirement check
targets the correct operation when the resource has relationships.

Refs api-platform#6738

Co-authored-by: soyuka <soyuka@users.noreply.github.com>
@soyuka soyuka force-pushed the fix/jsonapi-post-id-not-required branch from 58e1bcb to 92dde53 Compare June 5, 2026 12:31
@soyuka soyuka merged commit 1bc670c into api-platform:4.3 Jun 5, 2026
110 of 112 checks passed
@soyuka
Copy link
Copy Markdown
Member

soyuka commented Jun 5, 2026

Merging as bug fix as it is a specification update, needs to be documented still.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

For JSON:API, the ID is not required in the POST request

2 participants